Expertus metuit
Git в реальной жизни
Опубликовано 2016-10-26 в 15:32

За несколько месяцев активного внедрения git в компании я понял, что практически все обучающие статьи по этой теме бесполезны. Главная их проблема — они написаны для одиночек и почти полностью игнорируют аспекты командной работы именно технических специалистов. Технарям не нужно детально объяснять, как инициализировать новый репозиторий или как коммитить изменения, это всё ненужная механическая информация, которую легко найти, но вот даже базового понимания, как работает git, оно не даёт. К сожалению, в git очень много команд и очень много «непонятных» и «тупиковых» ситуаций, возникающих на ровном месте. Я специально пишу «непонятных», так как бо́льшая часть из них разруливается очень просто, нужно только понимать, что именно произошло.

Если вы хотите с разбега погрузиться во внутренности git, очень рекомендую статью Git from the inside out, в ней детально расписываются низкоуровневые механизмы работы репозитория.

А в моей статье я подробно расскажу о нюансах работы с git, на которые вы практически гарантированно наткнётесь в реальной работе, однако в обычных туториалах о них не прочитаете. Также я расскажу о типичных ошибках, заблуждения и вредных привычках. Некоторые вещи могут показаться банальными и очевидными, но поверьте, для кого-то они оказались неожиданными.

Также я старался все разделы статьи упорядочить по мере усложнения и расширения концепции. Все примеры кода должны воспроизводиться у каждого. Крайне желательно иметь linux/macos с терминалом под рукой. О совсем примитивных вещах типа установки git я тут писать не буду.

Весь текст полностью оригинален, не является переводом и базируется исключительно на официальной документации.

CLI vs. GUI

Я категорически не рекомендую начинать работу с git с GUI-клиента, это крайне вредная практика для новичков, поскольку GUI скрывает кучу нюансов и особенностей системы, которые можно увидеть только из стандартного клиента в командной строке. Переходить на GUI можно только после того, как вы достаточно освоите концепты git в консольной версии.

Консольная версия очень информативна и практически на любой команде в случае проблем выдаёт сразу несколько возможных вариантов их разрешения. Плюс man-страницы очень полезны, если нужно быстро узнать аргументы конкретной команды (например, man git-add о команде git add или man git-checkout о команде git checkout, принцип вы должны понять).

Язык и терминология

Нормальных русскоязычных эквивалентов терминологии git не существует. Официальный перевод документации и книги на сайте git-scm.org очень косноязычен и весьма далёк от совершенства. Поэтому в этой статье я буду писать тем языком, на котором разговаривают программисты в обычной жизни, так что готовьтесь морально к словам типа «вмержить» и «запушить».

Вы должны понимать некоторые базовые термины, которые используются во всём тексте.

рабочая копия / рабочий каталог / рабочая директория / working directory
это каталог, в котором находятся файлы вашего проекта, в нашем случае — это всё, что вне каталога .git
каталог репозитория
это каталог с собственно содержимым репозитория: конфиг, внутренняя база объектов и так далее, обычно этот каталог называется .git

Миграция сознания с Subversion

Очень часто люди приходят в git с солидным опытом в subversion, к сожалению, практически никакой пользы от этого опыта не будет, всё равно придётся полностью переучиваться. Базовые концепты двух систем совершенно разные и ломка при переходе гарантирована.

В официальной документации превосходно описаны базовые концепты git, однако у пользователей по непонятной причине возникают серьёзные проблемы с их пониманием. Но текст этот крайне важен, поскольку там описывается абсолютно всё, что нужно для осознания сути git, буквально всё. Я эти ключевые концепты здесь перечислю:

Снапшоты, а не патчи
Коммиты в git являются «снапшотами» состояния репозитория, а не наборами изменённых строк по сравнению с предыдущим коммитом, как в subversion, например. Ближайшей аналогией может послужить система снапшотов в VirtualBox/VMWare.
Практически все операции локальны
В отличие от subversion, практически все операции над репозиторием происходят в локальном репозитории разработчика. При этом локальный репозиторий не является точной копией центрального, куда отправляются не все коммиты подряд, а только те, которые решил «опубликовать» сам разработчик.
Git построен вокруг контроля целостности
Для всего содержимого считаются SHA1-чексуммы. Благодаря чексуммам обеспечивается связь коммитов, а конкретная чексумма является уникальным идентификатором коммита. При этом в подсчёт чексуммы коммита включается чексумма его родительского коммита, поэтому все они образуют ориентированный (направленный) ациклический граф.
В git данные только добавляются
Практически все операции в git сводятся к добавлению новых данных. Другими словами, если вы всё делаете правильно, вы всегда можете ««откатить» практически любую ошибку, в git крайне сложно что-то потерять насовсем.
Три состояния файлов
В системах типа subversion в рабочей копии любой файл может находиться в двух состояниях: commited и modified. В git добавлено третье промежуточное состояние — staging area. На мой взгляд, это не очень нужная сущность, однако она есть и я о ней расскажу в отдельном разделе подробнее.

Инициализация репозитория

Практически любой туториал из интернета начинается с команды git init, однако в реальности её почти никто никогда не использует, все сразу начинают работу с уже существующим репозиторием на центральном сервере компании (обычно это github, gitlab или Stash/bitbucket). Поэтому я не буду слишком много внимания уделять первому появлению репозитория на вашей машине, просто склонируйте любой репозиторий с интернета и поиграйтесь с ним. Например, так:

$ git clone https://github.com/sigsergv/bootstrap.git
Cloning into 'bootstrap'...
remote: Counting objects: 99074, done.
remote: Compressing objects: 100% (27/27), done.
remote: Total 99074 (delta 8), reused 0 (delta 0), pack-reused 99047
Receiving objects: 100% (99074/99074), 89.94 MiB | 311.00 KiB/s, done.
Resolving deltas: 100% (65840/65840), done.
Checking connectivity... done.

Этот репозиторий я буду дальше в статье использовать для демонстрации функций git. Я специально «форкнул» его к себе в гитхаб, чтобы зафиксировать состояние на момент написания статьи. Вы можете смело повторять все примеры ниже, результат должен быть таким же, как в тексте.

Также сразу нужно указать git своё имя и email, чтобы он мог их использовать при операциях (вы можете свои указать, а не мои), эту команду нужно запускать внутри рабочей копии репозитория:

$ git config user.name "Sergey Stolyarov"
$ git config user.email "[email protected]"

Зависимость от настроек

Поведение git чрезвычайно сильно зависит от настроек локального репозитория. В данной статье я предполагаю, что у вас стандартные настройки по умолчанию и все примеры рассчитаны именно на это. Если в какой-то из команд есть сильная зависимость от конфига, я постараюсь этот момент отдельно прояснить.

Структура локального репозитория

Локальный git-репозиторий представляет собой каталог, в котором находятся файлы рабочей копии и специальный каталог .git, в котором хранится собственно репозиторий: файлы, каталоги, коммиты, ветки и так далее. Обо всех этих сущностях я расскажу дальше по тексту.

Программа git понимает, что вы находитесь в репозитории, независимо от уровня вложенности. Другими словами, вы можете погрузиться на несколько уровней вглубь рабочей копии и программа всё равно будет знать, что вы внутри этого репозитория.

Не надо лазить ручками в файлы внутри каталога .git, это чревато непредсказуемыми проблемами, максимум, что можно себе позволить — это аккуратно редактировать конфиг репозитория в файле .git/config. Хотя даже для этого лучше пользоваться командой git config.

Итак, запомните: в каталоге .git лежит репозиторий, а всё что вне его — это рабочая копия. Все команды git в итоге производят какие-то операции над файлами внутри каталога .git, однако вам не нужно знать детально, что именно там происходит.

Staging area

Staged — это особое состояние файла из репозитория, по-русски в официальной документации оно называется «подготовленное», «подготовленный файл». По сути все staged-файлы являются предварительной заготовкой для коммита, если вы наберёте команду git commit -m "Commit message" то будет создан коммит на основе текущей staging area. Файл становится «подготовленным» после команды git add <FILENAME>, где <FILENAME> — имя модифицированного файла.

В subversion команда svn commit сразу создаёт коммит на основе модифицированных файлов.

В чём преимущества staging area:

  • вы можете выбрать для коммита только нужные изменения, например, вы можете взять только определённые файлы или же часть изменений из одного файла;
  • частично следствие предыдущего пункта, вы можете из одного набора изменений сделать несколько коммитов, например, для каждой логической подсистемы в коде отдельный коммит.

Если вам сама концепция staging area не нравится, можете её игнорировать: команда git commit <FILE> сразу коммитит указанный файл (или каталог), минуя стадию stage. Если вы хотите закоммитить вообще все изменённые файлы из рабочей копии, используйте команду git commit -a.

Вот небольшой пример, как это работает. Возьмём репозиторий bootstrap, который мы ранее клонировали. Дальше листинг выполненных команд:

$ echo 'change 1' >> README.md
$ git add README.md
$ echo 'change 2' >> README.md
$ git status
On branch v4-dev
Your branch is up-to-date with 'origin/v4-dev'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   README.md

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   README.md

Из статуса видно, что файл README.md содержит изменения как в staging area, так и вне её. Если набрать команду git commit -m 'message', то закоммитятся только изменения, помеченные как staged:

$ git commit -m 'Commmit message bla-bla-bla'
[v4-dev 77491ff] Commmit message bla-bla-bla
$ git status
 1 file changed, 1 insertion(+)
On branch v4-dev
Your branch is ahead of 'origin/v4-dev' by 1 commit.
  (use "git push" to publish your local commits)
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   README.md

no changes added to commit (use "git add" and/or "git commit -a")

А вот если выполнить команду git commit ., то закоммичены будут вообще все изменения, начиная с текущего каталога:

$ git commit -m 'Commit everything' .
[v4-dev 3302837] Commit everything
 1 file changed, 2 insertions(+)
$ git status
On branch v4-dev
Your branch is ahead of 'origin/v4-dev' by 1 commit.
  (use "git push" to publish your local commits)
nothing to commit, working tree clean

В общем случае я не советую использовать аргумент -m при коммите, гораздо безопаснее редактировать коммит-сообщение в редакторе, который откроется после ввода команды, так как там показывается, какие именно файлы будут закоммичены и можно сразу отловить много ошибок. Вот, например, как будет выглядеть шаблон commit message в случае запуска git commit:

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# On branch v4-dev
# Your branch is up-to-date with 'origin/v4-dev'.
#
# Changes to be committed:
#       modified:   README.md
#
# Changes not staged for commit:
#       modified:   README.md
#

А вот текст шаблона сообщения для команды git commit .:

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# Explicit paths specified without -i or -o; assuming --only paths...
# On branch v4-dev
# Your branch is up-to-date with 'origin/v4-dev'.
#
# Changes to be committed:
#       modified:   README.md
#

Команда git commit -a коммитит вообще все изменённые файлы, включая staging area.

Простой принцип: сразу после изменения файл становится «изменённым» (modified), после команды git add <FILENAME> файл становится «подготовленным» (staged).

Ну и финальное замечание на всякий случай: коммит является локальной операцией, она выполняется на локальном репозитории, а не на удалённом!

Пользуйтесь командой git status

Старайтесь почаще запускать команду git status, чтобы просмотреть текущее состояние рабочей копии локального репозитория. Помимо состояния эта команда также показывает возможные команды, которые вы захотите выполнить дальше. Вот пример:

$ rm LICENSE
$ rm composer.json
$ echo 123 >> README.md
$ git status
On branch v4-dev
Your branch is up-to-date with 'origin/v4-dev'.
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    deleted:    LICENSE
    modified:   README.md
    deleted:    composer.json

no changes added to commit (use "git add" and/or "git commit -a")

Сначала мы вносим изменения в файлы, потом запускаем git status. В выводе мы видим следующее:

On branch v4-dev
Название ветки, в которой мы находимся: v4-dev
Your branch is up-to-date with 'origin/v4-dev'.
Состояние рабочей копии по сравнению с «оригинальным» образом из центрального репозитория.
Changes not staged for commit:
Дальше идёт список изменённых файлов и возможные команды для этих файлов с краткими пояснениями. По каждому изменённому файлу указывается, каким именно образом он бы изменён, например, modified или deleted.

В примере выше мы удалили файл LICENSE. Наверное, зря и хотим его восстановить, из текста статуса видно, что мы можем отменить изменения командой git checkout, попробуем восстановить LICENSE:

$ git checkout -- LICENSE

Как и многие unix-style команды, git в случае успешного выполнения ничего на экране не показывает, а если происходит ошибка, то показывает.

Посмотрим опять статус и убедимся, что файл LICENSE исчез из списка изменённых и снова появился в каталоге:

$ git status
On branch v4-dev
Your branch is up-to-date with 'origin/v4-dev'.
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    modified:   README.md
    deleted:    composer.json

no changes added to commit (use "git add" and/or "git commit -a")
$ head -n1 LICENSE
The MIT License (MIT)
$

Теперь добавим наши изменения в файле README.md в staging area, сделаем их «подготовленными». Обратите внимание, что я говорю «изменения», а не файл, мы можем добавить не весь файл, а только его часть! Но сейчас добавим весь и сразу посмотрим новый статус:

$ git add README.md
$ git status
On branch v4-dev
Your branch is up-to-date with 'origin/v4-dev'.
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

    modified:   README.md

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

    deleted:    composer.json

Видим, что теперь в статусе появилась секция со staged файлами, то есть подготовленными для коммита, это секция Changes to be committed; для каждого файла показывается тип изменений и возможные команды, в нашем случае это всего одна команда для операции unstage, эта операция возвращает файл в изменённое состояние из staged. Операция делается командой git reset HEAD <file>.

Unstage может понадобиться, чтобы, например, убрать из подготовленных файлов лишние, добавленные по ошибке.

Если мы хотим «откатить» вообще всё, то придётся выполнить две команды: сначала unstage подготовленные файлы, затем восстановить изменённые. Делается это так:

$ git reset HEAD
Unstaged changes after reset:
M   README.md
D   composer.json
$ git checkout -- .
$ git status
On branch v4-dev
Your branch is up-to-date with 'origin/v4-dev'.
nothing to commit, working directory clean
$

Пользуйтесь gitk

gitk — это браузер для локального репозитория, мощная программа, которая очень поможет вам визуально оценить историю изменений и состояние репозитория. В debian/ubuntu gitk идёт отдельным одноимённым пакетом и ставится командой sudo apt install gitk. В виндовой версии входит в состав дистрибутива git.

Запускать gitk нужно из каталога репозитория, всё поведение программы определяется аргументами при запуске. Запуск без аргументов открывает браузер для текущего состояния репозитория (текущая ветка или коммит, подготовленные файлы, модифицированные файлы).

Запуск с аргументом --all открывает дерево со всеми тегами, ветками и т.п.

a

В верхней части показываются коммиты плюс изменённые, плюс подготовленные файл,два последних как «псевдокоммиты». Вы можете выбрать нужный коммит и тогда в нижней части будет показано его содержимое. gitk не является «графическим интерфейсом», через него нельзя выполнять команды, это всего лишь удобный браузер.

Коммиты

Коммит является центральной сущностью git, и для его полного понимания нужно знать о внутреннем устройстве репозитория.

Как я уже говорил выше, коммит по сути является снапшотом («снимком состояния») файлов. Когда вы коммитите изменения командой git commit, система создаёт новый снапшот. Это не набор изменений, а именно полный снапшот со всеми файлами репозитория. Git хорошо сжимает коммиты, так что итоговый размер получается небольшим.

Коммит состоит из следующих компонентов:

  • набор файлов — это просто все файлы из снапшота в виде блобов (blob), в каждом блобе содержимое одного файла, блоб идентифицируется его SHA1-хешем, это только содержимое, без пути к файлу и без имени файла;
  • дерево файлов — это древовидная структура, описывающая положение файлов в каталоге файловой системы, каждый «файл» — это его имя с путём, а также SHA1-сумма/идентификатор соответствующего блоба;
  • метаданные — это набор параметров коммита, например, сообщение, автор, дата и так далее;
  • идентификатор родительского коммита (или несколько родительских коммитов).

От всего этого набора данных считается SHA1-сумма, которая становится идентификатором коммита.

Все коммиты образуют ориентированный граф, направление идёт от «дочернего» к «родительскому» коммиту. Другими словами, каждый коммит знает своего родителя, но родители не знают своих детей.

Это очень важный момент! Вы должны запомнить, что коммиты-снапшоты образуют ориентированный граф и что любой коммит зависит от всех своих предков по цепочке зависимостей. Представление репозитория как дерева/графа вам очень поможет в будущем для понимания сути операций merge и rebase.

Самый первый коммит в репозитории не имеет родительского, в нашем образцовом репозитории идентификатор самого первого коммита — eb81782cdbdc68aaebe4fa561b5fbb73ef866611. Вот как можно посмотреть его содержимое как низкоуровневого объекта:

$ git cat-file -p eb81782cdbdc68aaebe4fa561b5fbb73ef866611
tree decabeb7ee45940c013563b4425c580d4e760833
author Mark Otto <[email protected]> 1303937631 -0700
committer Mark Otto <[email protected]> 1303937631 -0700

Porting over all Blueprint styles to new Baseline repo

Здесь мы видим идентификатор объекта «дерево-файлов» — decabeb7ee45940c013563b4425c580d4e760833, автора с указанием имени «Mark Otto», е-мейла «[email protected]» и времени «1303937631 -0700», коммитера в таком же формате и текст комментария к коммиту.

В принципе, вам не нужно набирать весь идентификатор целиком. Во всех случаях, где требуется указать SHA1-идентификатор объекта, вы можете использовать только первые его цифры, git дальше сам найдёт полный идентификатор, который начинается с указанной строк. Обычно используются только первые 7 символов идентификатора. Вот пример, как это работает:

$ git cat-file -p eb81782
tree decabeb7ee45940c013563b4425c580d4e760833
author Mark Otto <[email protected]> 1303937631 -0700
committer Mark Otto <[email protected]> 1303937631 -0700

Porting over all Blueprint styles to new Baseline repo

Однако если вы укажете совсем мало символов идентификатора, git будет ругаться:

$ git cat-file -p eb81
error: short SHA1 eb81 is ambiguous.
fatal: Not a valid object name eb81

Идентификатор дерева файлов указывает на объект, который мы может точно так же посмотреть:

$ git cat-file -p decabeb7ee45940c013563b4425c580d4e760833
040000 tree f111dbd5b869dc4f2e26e35886f1cb3ec282d786   img
100644 blob 9aa2355b0d5f3dd324c8191637703c944ca14fca    index.html
040000 tree 504f144696c6637925d6e57864cc23aadeee9e21    js
040000 tree a5433da68003ff087c7a234dbf05a3ee6c76e759    less
100644 blob 36b357b31afdee5fffbeddeba3144ffbd98d9778    readme.markdown

Каждая строчка описывает либо файл, либо вложенное дерево (по сути — подкаталог).

Идентификатор второго коммита — 677b5554f34d8206b7424796448ee1b5a9ba0e87 (или сокращённо 677b555), вот информация по нему:

$ git cat-file -p 677b555
tree 3196c00945e52af8e62689a5ab774dc268331400
parent eb81782cdbdc68aaebe4fa561b5fbb73ef866611
author Mark Otto <[email protected]> 1303940996 -0700
committer Mark Otto <[email protected]> 1303940996 -0700

Remove the unnecessary global.js file, remove the old baseline grid image, add in hashgrid, update readme to remove finished todos;

Видим, что добавился дополнительный идентификатор родительского коммита eb81782cdbdc68aaebe4fa561b5fbb73ef866611.

Важнейшим свойством такой схемы является связность и целостность. Другими словами, если у вас есть коммит с некоторым идентификатором, то вы можете с большой степенью уверенности предполагать, что этот коммит будет абсолютно одинаковым вообще во всех репозиториях и что у него в предках будут одинаковые коммиты во всех репозиториях. Вы не можете изменить никакой коммит так, чтобы не изменились идентификаторы всех последующих коммитов.

Целостность обеспечивается надеждой, что не случится коллизии SHA1-сумм объектов. Теоретически возможно изменить коммит так, чтобы его SHA1-сумма не изменилась. Однако в реальности это практически невозможно.

Таким образом, git-репозиторий представляет собой очень простое key-value хранилище объектов. В роли объектов выступают блобы, деревья файлов и коммиты. У каждого объекта есть идентификатор — его SHA1-сумма. Низкоуровневая команда git cat-file p <object id> позволяет просмотреть содержимое объекта любого типа по его идентификатору.

Все высокоуровневые команды git в итоге сводятся к командам по управлению низкоуровневыми объектами нескольких типов, подробнее об этом можно почитать в официальной книге, в разделе Git изнутри - Объекты в Git.

А где каталоги?

git не хранит информацию о каталогах, он хранит только информацию о путях к файлам. Поэтому вы не сможете добавить пустой каталог в проект, во внутренней базе git просто нет объекта для этого. Если вам всё же он нужен, можете его создать, положить внутрь какой-нибудь пустой файл и закоммитить этот файл.

Автор коммита и автор изменений

У объекта-коммита есть автор (author) и коммитер (committer). На разницу между ними можете пока просто забить. В большинстве случаев это одинаковые значения, но иногда разные. Пока можете считать, что автор — это кто написал изначальный код, а коммитер — это тот, кто его закоммитил.

Теги, референсы и ветки

Коммиты-снапшоты в git образуют ориентированный граф, эти коммиты никогда не меняются и мы можем любой из них пометить тегом (tag). Тег — это уникальная метка-строка, указывающая на конкретный коммит, по сути он является удобным коротким именем для длинного идентификатора коммита. Обычно тегами помечаются важные коммиты, например, коммит для конкретной версии продукта.

Посмотреть список всех тегов можно командой git tag:

$ git tag
v1.0.0
v1.1.0
v1.1.1
...
v4.0.0-alpha.4
v4.0.0-alpha.5
$ 

Вы можете использовать тег везде, где требуется идентификатор коммита. Например, вы можете переключить рабочую копию на коммит конкретной версии командой типа git checkout v1.1.1.

Описанные выше теги — это внешние сущности для внутренней базы объектов, они никак не фигурируют в зависимостях между коммитами и при желании тег можно удалить и «повесить» на другой коммит. В терминологии git такие теги называется облегчёнными (lightweight), помимо них также существуют аннотированные, в них кроме идентификатора коммита можно включить произвольный текст, дату и PGP-подпись. Про аннотированные теги я немного расскажу в разделе о подписывании объектов.

Тег является ссылкой (reference, ref), я в этой статье (и в других моих текстах про git) использую слово референс. Референс всегда «указывает» на какой-нибудь конкретный идентификатор коммита, а все референсы хранятся в каталоге репозитория .git отдельно от внутренней базы объектов в файле .git/packed-refs и каталоге .git/refs. .git/packed-refs — это простой текстовый файл, его содержимое выглядит примерно так:

# pack-refs with: peeled fully-peeled 
fdb5af3bd919c90720c47953f191c652a9c8fd93 refs/remotes/origin/bardiharborow-sauce
a22c696485ff1736140f56cd62d5e69434bf9df6 refs/remotes/origin/blockquote-border-width
16c63765d04d5dd70568113bc19659dc754f1f1e refs/remotes/origin/fixes-15534
... куча примерно таких же строчек
48e6ccc7a59532df69cff957d7466b459c9f6dcf refs/tags/v2.0.3
... ещё куча

Каждая строка состоит из идентификатора коммита и полного имени референса. Полное имя имеет иерархическую структуру, элементы иерархии разделяются символом «/», они могут быть внутренними категориями или именами. В листинге выше категории — это refs, remotes, tags; имена — origin, bardiharborow-sauce, fixes-15534, v2.0.3.

Полное имя прозрачно транслируется на файловую структуру в каталоге .git/refs, например, референсу refs/tags/v2.0.3 соответствует такой же путь внутри каталога .git, это путь до простого текстового файла, внутри которого идентификатор коммита. Если референс не используется, он хранится в файле .git/packed-refs вместе с остальными. Посмотреть все референсы в текущем репозитории можно командой git show-ref

Ещё одним типом референса является ветка (branch). Ветки в git вызывают серьёзные затруднения у новичков. В subversion коммиты представляют собой наборы патчей, а ветка (branch) в терминологии SVN — это всего лишь отдельный каталог в общем дереве файлов. Ветка в git — это совершенно иная сущность, не имеющая ничего общего с ветками в subversion.

По сути ветка в git является динамическим тегом, меткой, которая указывает на конкретный коммит, но значение этой метки при каждом новом коммите автоматически меняется на новое. Как и тег, ветка является внешней сущностью для базы объектов и может указывать на любой коммит и никак не зависит от предыдущих коммитов в ветке.

В нашем образцовом репозитории веткой по умолчанию является v4-dev, её полное имя — refs/heads/v4-dev (да, здесь очередной пример неконсистентности внутренней структуры git, категория для веток в иерархии компонентов полного имени референса называется не branches, а heads). Посмотрим на последний коммит в ветке v4-dev:

$ git log -n1 v4-dev 
commit d1171ac44ad05a1b7244900b690840093d3e5573
Author: Mark Otto <[email protected]>
Date:   Sun Oct 30 15:21:53 2016 -0700

    grunt

А теперь посмотрим, что хранится в файле .git/refs/heads/v4-dev:

$ cat .git/refs/heads/v4-dev
d1171ac44ad05a1b7244900b690840093d3e5573

Там действительно лежит идентификатор нужного коммита.

Теперь посмотрим опять на команду git log -n1 v4-dev, в ней мы взяли один последний элемент из истории коммитов в ветке v4-dev, однако вместо v4-dev мы можем использовать любой другой референс или даже любой идентификатор коммита. git log показывает на самом деле цепочку коммитов, начиная с указанного, затем его родителя, родителя его родителя и так далее до самого первого коммита, у которого нет предка.

Когда вы передаёте в команду референс, скажем, REFNAME, git должен его преобразовать в конкретный идентификатор коммита, для этого он сначала пытается короткое имя преобразовать в полное, попутно определяя тип референса. Полностью этот процесс описан в документации gitrevisions, а упрощённо список проверямых полных референсов выглядит так (поиск идёт сверху вниз и прекращается при совпадении):

  • refs/REFNAME
  • refs/tags/REFNAME
  • refs/heads/REFNAME
  • refs/remotes/REFNAME

Обычно конфликтов между референсами не возикает, однако у вас в команде практически неизбежно кто-нибудь создаст, например, тег, совпадающий с названием ветки. Мы можем это сами проделать на нашем тестовом репозитории (создадим тег master, указывающий на коммит 677b5554f34d8206b7424796448ee1b5a9ba0e87):

$ git tag master 677b5554f34d8206b7424796448ee1b5a9ba0e87
$

git позволил это сделать и не выдал никаких сообщений об ошибках. Если мы теперь попробуем сделать git checkout master, то увидим такое:

$ git checkout master 
warning: refname 'master' is ambiguous.
Switched to branch 'master'
Your branch is up-to-date with 'origin/master'.

Если мы хотим переключиться на тег master (а не на ветку с таким же именем), то придётся использовать «квалифицированное» имя:

$ git checkout tags/master
Note: checking out 'tags/master'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b <new-branch-name>

HEAD is now at 677b555... Remove the unnecessary global.js file, remove the old baseline grid image, add in hashgrid, update readme to remove finished todos;

Вы можете удалить тег:

$ git tag -d master       
Deleted tag 'master' (was 677b555)

В целом git в нужном контексте действует достаточно интеллектуально и «резолвит» референс в наиболее подходящий для данного контекста объект. Референсы используются очень активно и если вы поймёте базовые принципы их формирования, вы автоматически научитесь понимать кучу «магических» команд.

На один коммит может указывать несколько разных референсов. Например, в какой-то момент референс ветки и тега могут совпадать.

Создание новой ветки

Новую ветку можно создать несколькими способами. Первый способ прямолинейный:

$ git branch new-branch

Эта команда создаст новую ветку на базе последнего коммита в рабочей копии. Однако вы можете указать конкретный коммит, с которого должна начаться ветка. Вместо коммита вы можете указать любой референс и тогда ветка будет создана на основе того коммита, на который указывает референс в момент вызова команды. Я специально этот момент выделю:

ветка будет создана на основе того коммита, на который указывает референс в момент вызова команды

Создавайте новые ветки только на базе референсов из внешнего репозитория

И не забывайте делать git fetch перед этим.

Допустим, вы хотите начать новую фичу, используя в качестве стартовой точки актуальное состояние ветки master, делается это так:

$ git fetch
$ git branch new-branch origin/master

Такая команда создаёт новую ветку, однако рабочая копия по-прежнему находится на старом коммите.

Второй способ создания ветки через команду git checkout -b <new_branch> [<start point>] создаёт новую ветку и одновременно переключает рабочую копию на неё:

$ git checkout -b new-branch origin/master
Branch new-branch set up to track remote branch master from origin.
Switched to a new branch 'new-branch'

Если <start point> не указать, то ветка будет создана на основе текущего коммита в рабочей копии и рабочая копия будет переключена на эту ветку. Обратите внимание, что в рабочей копии находится тот же самый коммит, но ветка уже другая!

Переключение рабочей копии

Вы можете всегда «переключить» рабочую копию на произвольный коммит, так сказать «восстановить снапшот», делается это командой git checkout <commit id>, например, переключимся на второй коммит (см. выше его идентификатор):

$ git checkout 677b5554f34d8206b7424796448ee1b5a9ba0e87
Note: checking out '677b5554f34d8206b7424796448ee1b5a9ba0e87'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b <new-branch-name>

HEAD is now at 677b555... Remove the unnecessary global.js file, remove the old baseline grid image, add in hashgrid, update readme to remove finished todos;

Текст на экране сообщает подробности о новом состоянии рабочей копии: она находится в режиме detached HEAD плюс несколько фраз о ветках. Что такое HEAD и detached HEAD я расскажу чуть позже.

Переключиться можно также на любой референс, например, на тег:

$ git checkout v2.1.1
Note: checking out 'v2.1.1'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b <new-branch-name>

HEAD is now at c52368d... last rebuild before 2.1.1 release

Статус на такой рабочей копии покажет что-то вроде:

% git status
HEAD detached at v2.1.1
nothing to commit, working directory clean

Ну и, естественно, можно переключиться на произвольную ветку, вернёмся на нашу изначальную ветку v4-dev:

$ git checkout v4-dev
Previous HEAD position was c52368d... last rebuild before 2.1.1 release
Switched to branch 'v4-dev'
Your branch is up-to-date with 'origin/v4-dev'.
$

Специальный референс: HEAD

HEAD — это специальный референс, содержащий идентификатор коммита, на который в данный момент переключена рабочая копия. При каждом переключении его значение меняется, также он обновляется при коммите.

Фраза detached HEAD означает, что в рабочей копии репозитория находится сейчас отдельный коммит, а не ветка. Если вы сделаете какие-либо изменения и закоммитите их, то получите «потерянный» коммит, не привязанный ни к какой ветке, вы можете легко его потерять, у вас нет простого способа на него снова переключиться и так далее.

Референс HEAD удобно использовать везде, где нужно ссылаться на текущий актуальный коммит в рабочей копии.

Физически значение HEAD хранится в файле .git/HEAD.

Вместо HEAD можно везде писать @.

Журнал коммитов / log

Команда git log позволяет просматривать граф коммитов. По умолчанию git log открывает журнал коммитов, начиная с текущего коммита в рабочей копии.

У команды много аргументов и режимов работы, я о них тут писать не буду.

Reference log / reflog / рефлог

Рефлог — это история «переключений» рабочей копии. Каждый раз, когда вы делаете git checkout <COMMIT ID> или git checkout <BRANCH>, в рефлог записывается информация об этом. Просмотреть журнал можно командой git reflog, вот как он выглядит на только что склонированном новом репозитории:

$ git reflog
d1171ac HEAD@{0}: clone: from https://github.com/sigsergv/bootstrap.git
$

Если теперь переключиться на другой коммит или ветку, то в рефлоге появится новая запись:

$ git checkout v2.1.1
Note: checking out 'v2.1.1'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b <new-branch-name>

HEAD is now at c52368d... last rebuild before 2.1.1 release
$ git reflog
c52368d HEAD@{0}: checkout: moving from v4-dev to v2.1.1
d1171ac HEAD@{1}: clone: from https://github.com/sigsergv/bootstrap.git

Переключимся снова на ветку v4-dev:

$ git checkout v4-dev
Previous HEAD position was c52368d... last rebuild before 2.1.1 release
Switched to branch 'v4-dev'
Your branch is up-to-date with 'origin/v4-dev'.
$ git reflog
d1171ac HEAD@{0}: checkout: moving from c52368d3c5984b28e6a71e5e1240afdd788fc2e6 to v4-dev
c52368d HEAD@{1}: checkout: moving from v4-dev to v2.1.1
d1171ac HEAD@{2}: clone: from https://github.com/sigsergv/bootstrap.git
$ 

Формат вывода git reflog регулируется аргументами в командной строке, в списке записей сверху находится самая последняя и дальше идут более ранние. В нашем варианте в каждой строке сначала идёт идентификатор коммита, дальше специальный референс вида HEAD@{N}, который можно трактовать как «коммит, который был в рабочей копии N обновлений HEAD назад».

В команде также можно указать название ветки, чтобы посмотреть, как эта ветка менялась с течением времени в локальном репозитории:

$ git reflog v4-dev
d1171ac v4-dev@{0}: clone: from https://github.com/sigsergv/bootstrap.git
$

Ветка master

Ветка с названием master — это исторически сложившаяся традиция, master можно спокойно переименовать или даже удалить. Эта ветка никак особо не обрабатывается git и совершенно равноправна по сравнению с другими.

Обычно master используется как главная ветка проекта.

Удаление ветки не удаляет коммиты

Ветка по сути является внешней динамически изменяемой меткой, которая перемещается по графу репозитория при каждом новом коммите. Таким образом, удаление ветки всего лишь удаляет эту метку, не трогая сами коммиты.

Если вы случайно удалили ветку, то ваши коммиты до какого-то времени останутся в локальном репозитории, однако со временем сборщик мусора их удалит, если на них не будет ссылок со стороны тегов или других веток.

Кроме того, ветки — очень «лёгкие», это всего лишь ссылка на коммит и практически не занимает места.

Внешние репозитории / remote

Git — распределённая система и ваш локальный репозиторий склонирован из какого-то внешнего. Такой внешний репозиторий в местной терминологии называется remote. В официальной документации на русском языке внешние репозитории называются удалёнными, однако лично мне это слово очень не нравится, так как слишком многозначное.

Внешние репозитории идентифицируются в локальном через короткое имя. Каждому имени соответствует некоторый URL собственно репозитория. При клонировании сразу автоматически создаётся внешний репозиторий под названием origin.

Крайне важный момент: origin — это всего лишь имя для сущности remote, постарайтесь чётко разделить эти два понятия.

Для работы с внешними репозиториями используется команда git remote. Например, список всех внешних репозиториев можно посмотреть такой командой:

$ git remote
origin
$

Это компактная версия, которая возвращает только имена, посмотреть URL репозитория origin можно так:

$ git remote get-url origin
https://github.com/sigsergv/bootstrap.git
$

Выше был типичный текст из туториала, из которого сложно понять, что такое remote на самом деле. На самом деле всё очень просто, если вы поняли предыдущие разделы статьи. Каждый репозиторий (в том числе и сторонний, определяемый URL и коротким именем) представляет собой набор связанных между собой объектов-коммитов. Команда git fetch «вытаскивает» в локальный репозиторий объекты из внешнего, а git push наоборот, отправляет новые локальные объекты-коммиты во внешний репозиторий из локального.

Благодаря структуре внутренней базы git, эта операция исключительно простая и сводится к простому копированию объектов, у которых сохраняются все их идентификаторы и связи.

Вместо git fetch обычно используется git pull, эта команда сначала скачивает новые объекты, а потом пытается обновить текущую ветку и текущую рабочую копию путём merge (или rebase).

Операция fetch — не деструктивная, её можно безопасно запускать практически в любое время, она не вносит никаких изменений в локальную рабочую копию. Часто можно прочитать, что в локальном репозитории находится копия внешнего, которая обновляется исключительно командой git fetch, в реальности это немного не так — в локальном репозитории все коммиты абсолютно равноправны, они находятся в одном пространстве и локальные никак не отличаются от внешних. Все отличия исключительно в метках, которые развешаны на дереве коммитов.

Все референсы с внешнего репозитория лежат в специальном «пространстве имён». Полное имя референса на ветку v4-dev с внешнего репозитория с именем origin выглядит так: refs/remotes/origin/v4-dev, это совершенно обычный референс и на него можно спокойно переключить локальную рабочую копию:

$ git checkout origin/v4-dev
Note: checking out 'origin/v4-dev'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b <new-branch-name>

HEAD is now at d1171ac... grunt

Я использую сокращённый референс origin/v4-dev, чтобы найти этот коммит, git последовательно проверит следующие полные референсы:

  • refs/origin/v4-dev
  • refs/tags/origin/v4-dev
  • refs/heads/origin/v4-dev
  • refs/remotes/origin/v4-dev

Из них найдётся последний: refs/remotes/origin/v4-dev, именно на него будет переключена рабочая копия.

Все референсы, полные имена которых начинаются на refs/remotes, образуют по сути образ внешнего репозитория внутри локального. Обновляются они командой git pull/git fetch.

Если вы видите референс вида remotes/AAA/BBB, то это референс на ветку BBB с внешнего репозитория по имени AAA.

Референсы с внешнего репозитория можно совершенно спокойно менять локально, например, вы можете легко изменить значение референса на ветку внешнего репозитория. Вот пример:

$ git show-ref refs/remotes/origin/v3-dev
7e2741eb1074afc66bdf2fb5c2b65d476cf7f37e refs/remotes/origin/v3-dev
$ git show-ref refs/tags/v2.2.1
08a4e19fdcf5105312b9056e73e30d49ecc85913 refs/tags/v2.2.1
$ git update-ref refs/remotes/origin/v3-dev 08a4e19fdcf5105312b9056e73e30d49ecc85913
$ git show-ref refs/remotes/origin/v3-dev
08a4e19fdcf5105312b9056e73e30d49ecc85913 refs/remotes/origin/v3-dev
$

В этом примере мы поменяли значения референса ветки origin/v3-dev на совершенно посторонний коммит. Не делайте так никогда! Впрочем, git сам поддерживает целостность внешних референсов и при следующей операции git fetch сбросит ваши изменения:

$ git fetch
From https://github.com/sigsergv/bootstrap
 + 778ecbb...7e2741e v3-dev     -> origin/v3-dev  (forced update)
$ git show-ref refs/remotes/origin/v3-dev
7e2741eb1074afc66bdf2fb5c2b65d476cf7f37e refs/remotes/origin/v3-dev

Видим, что значение референса вернулось в правильное значение.

Вы можете одновременно работать с несколькими внешними репозиториями, более того, вы можете подключить совершенно посторонние, которые абсолютно никак не пересекаются с уже добавленными! В качестве примера я сейчас добавлю в наш образцовый локальный репозиторий ссылку на сторонний репозиторий проекта Yarn и сразу же вытащу оттуда его содержимое:

$ git remote add yarn https://github.com/yarnpkg/yarn.git
$ git fetch yarn
warning: no common commits
remote: Counting objects: 17271, done.
remote: Compressing objects: 100% (17/17), done.
remote: Total 17271 (delta 0), reused 17 (delta 0), pack-reused 17254
Receiving objects: 100% (17271/17271), 40.59 MiB | 2.17 MiB/s, done.
Resolving deltas: 100% (7502/7502), done.
From https://github.com/yarnpkg/yarn
 * [new branch]      0.10-stable -> yarn/0.10-stable
 * [new branch]      0.11-stable -> yarn/0.11-stable
...дальше много строчек...
 * [new tag]         v0.10.1    -> v0.10.1
 * [new tag]         v0.11.2    -> v0.11.2
 * [new tag]         v0.12.0    -> v0.12.0
...и ещё много...
$

Как мы видим, git понял, что это совершенно посторонний репозиторий, никак не пересекающийся с нашим:

warning: no common commits

Теперь в списке внешних репозиториев появилась новая строчка:

$ git remote
origin
yarn
$

И в списке всех внешних веток (показывается командой git branch -r) появились новые:

$ git branch -r
  origin/HEAD -> origin/v4-dev
  origin/bardiharborow-sauce
...
  origin/master
...
  origin/v4-palettes
  yarn/0.10-stable
...
  yarn/git-offline
  yarn/master
...
  yarn/update-check

Локальная ветка master «растёт» из репозитория origin, если мы хотим поработать с этой же веткой, но в репозитории yarn, мы должны эту ветку «вытащить» локально, создать её локальную версию. Имя master уже занято, поэтому возьмём имя master-yarn:

$ git checkout -b master-yarn yarn/master
Branch master-yarn set up to track remote branch master from yarn.
Switched to a new branch 'master-yarn'

Команда git checkout -b <NEW BRANCH> <START POINT> создаёт новую ветку с именем <NEW BRANCH>, которая заканчивается коммитом <START POINT>, а после создания переключает рабочую копию на эту новую ветку.

Фраза «Branch master-yarn set up to track remote branch master from yarn.» означает, что наша новая ветка master-yarn теперь связана с веткой master внешнего репозитория yarn. Это значит, что когда вы будете выполнять команду git pull, находясь в локальной ветке master-yarn, git сначала скачает все новые объекты с сервера внешнего репозитория (его URL был прописан в конфиге, когда мы делали команду git remote add), а затем попытается обновить эту ветку новыми коммитами, которые появились в ветке yarn/master с момента последнего предыдущего обновления.

Аналогично с командой push, но детальнее об этом и pull/fetch я расскажу позднее.

Когда вы работаете с веткой master-yarn, ваша рабочая копия полностью меняется. По сути происходит полное перезаписывание всех файлов.

Внешние ветки (remote-tracking branch)

Выше я уже упоминал о внешних ветках, их референсы выглядят так: <REMOTE>/<BRANCH NAME>, типичный пример: origin/master. Внешние ветки по сути отображают внешний репозиторий в вашем локальном. То есть ветка origin/master после каждой команды git fetch обновляет своё значение на актуальный идентификатор ветки master на внешнем репозитории. Аналогично происходит со всеми остальными внешними ветками.

Посмотреть список всех внешних веток со всех внешних репозиториев можно командой git branch -r:

$ git branch -r
  origin/HEAD -> origin/v4-dev
  origin/bardiharborow-sauce
  origin/blockquote-border-width
  origin/fixes-15534
  origin/font-stack-docs
  origin/gh-pages
  origin/list-group-border-color
  origin/mark-padding
  origin/master
  origin/responsive-display
  origin/responsive-spacers
  origin/revert-20962-v4-fix-unescaped-hash-data-urls
  origin/sauce-creds
  origin/v3-dev
  origin/v4-dev
  origin/v4-dev-xmr
  origin/v4-dev-xmr-jquery-slim
  origin/v4-docs-streamlined
  origin/v4-flex-utils
  origin/v4-grid-classes
  origin/v4-navbar-lever
  origin/v4-npm
  origin/v4-palettes

В первой строчке вы видите специальный референс origin/HEAD -> origin/v4-dev, он означает, что при клонировании репозитория веткой по умолчанию будет v4-dev.

Апстрим (upstream)

Апстримом принято называть внешний репозиторий, на основе которого создан локальный. Общая схема работы с апстримом предполагает периодические обновления локального новыми изменениями с апстрима, а также периодическую отправку новых локальных изменений в апстрим. Вы скачиваете новые коммиты с апстрима и закачиваете туда свои новые, так делают все остальные разработчики и в итоге у всех получаются синхронизованные локальные репозитории.

Работа с апстримом в git происходит через внешние ветки в локальном репозитории. Начнём с простого примера: когда мы склонировали наш образцовый репозиторий в самом начале, автоматически была создана одна локальная ветка с названием v4-dev. Сразу же автоматически для ветки v4-dev была назначена апстрим-ветка origin/v4-dev, посмотреть это можно такой командой:

$ git branch -vv --list v4-dev
* v4-dev d1171ac [origin/v4-dev] grunt

В квадратных скобках написано название внешней ветки, которая является апстримом для локальной. Такую локальную ветку принято называть отслеживаемой веткой (tracking branch), а внешнюю ветку, которую она отслеживает — апстрим-веткой (upstream branch).

Физически связь ветки с апстримом хранится в конфиге локального репозитория .git/config, если мы этот файл откроем в редакторе, то увидим там такую секцию для нашей ветки:

[branch "v4-dev"]
    remote = origin
    merge = refs/heads/v4-dev

В поле origin указывается имя внешнего репозитория (для него, кстати, тоже есть секция в конфиге), в поле merge указывается название ветки во внешнем репозитории, которая используется в качестве апстрима.

Переключение на внешнюю ветку

Если вы посмотрите на список локальных веток, то увидите, что он значительно короче списка внешних:

git branch -l
*  v4-dev
$

Если вы обычным способом переключитесь на ветку, скажем, origin/v4-npm, то вы окажетесь в состоянии detached HEAD, это логично, так как ветка origin/v4-npm является по сути веткой только для чтения.

Если вы хотите поработать над внешней веткой, то вам нужно создать локальную на основе текущего состояния внешней, а также назначить внешнюю апстримом для локальной. Это можно сделать одной командой:

$ git checkout v4-npm
Branch v4-npm set up to track remote branch v4-npm from origin.
Switched to a new branch 'v4-npm'
$

Команда git checkout умная, иногда даже слишком. В данной ситуации она пытается угадать, чего вы от неё хотите; так как локальной ветки с именем v4-npm нет, но есть такая ветка во внешнем репозитории origin/v4-npm, то git создаёт новую локальную ветку, называет её v4-npm, назначает для неё апстримом ветку origin/v4-npm и переключает локальную копию на свежесозданную ветку.

Как работает синхронизация локального репозитория с внешним

Вопрос из заголовка я специально хочу рассмотреть до рассказа о непосредственно операциях синхронизации и даже до примеров. Синхронизация в git устроена чрезвычайно просто. Действительно очень просто.

Напомню, что практически все операции в git происходят в локальном репозитории. Содержимое внешнего репозитория хранится вместе с локальными объектами, коммиты организованы в виде одного ориентированного графа, на определённые узлы которого развешаны метки со всех репозиториев: теги и ветки.

Вот несколько упрощённая схема, как работает команда git fetch origin, которая «скачивает» новые коммиты с внешнего репозитория origin:

  • по имени внешнего репозитория получает его URL;
  • при необходимости авторизуется;
  • скачивает новые объекты;
  • сохраняет объекты в базу;
  • обновляет референсы для данного внешнего репозитория (refs/remotes/origin/*).

Под «обновлением референсов» подразумевается их перезаписывание, если они были изменены; и добавление новых, если таких ещё не было или они были вручную удалены.

Таким образом, в локальном репозитории после git fetch всегда остаётся корректный образ внешнего репозитория, все изменения в референсах для внешних репозиториев будут принудительно перезаписаны.

Целостность системы обеспечивается автоматически, конфликты в принципе невозможны (напомню, что я говорю сейчас только об операции fetch).

Обратите внимание, это синхронизация репозитория, а не рабочей копии!

git pull и git merge

Команда git pull используется для обновления локального репозитория и для обновления текущей рабочей копии. По сути она является объединением команд git fetch (для обновления локального репозитория) и git merge (для обновления рабочей копии с апстрима). Вы должны очень чётко осознавать, что git pull объединяет две операции, главной из которых является именно git merge.

О git fetch я уже выше рассказывал, это недеструктивная простая операция, которая обновляет локальную базу объектов. В том числе обновляет и внешние ветки, то есть ветки с именами вида origin/*.

git merge отвечает за слияние двух графов коммитов. Если запускать git merge без аргументов, то в качестве коммита, с которого пойдёт слияние, будет взята апстрим-ветка из конфига. Напомню, что ветка в git — это по сути динамически сдвигающаяся метка, поэтому когда речь идёт о ветке, подразумевается коммит, на который в данный момент указывает метка ветки.

Для объединения git fetch и git merge в одну команду есть рациональное объяснение: вы можете забыть сделать git fetch и смержите свою ветку с неактуальным состоянием внешней ветки. Я это часто наблюдал в реальном проекте.

Упрощённо процесс мержинга двух коммитов можно описать так:

  • рабочая копия переключена на ветку B1 с топовым коммитом C1;
  • апстримом для ветки B1 является ветка origin/B1 с топовым коммитом X1;
  • запускаем команду git merge;
  • git выделяет коммиты, которые мы хотим смержить, это C1 и X1;
  • git ищёт коммит в графе, на котором «разошлись» ветки B1 и origin/B1 (другими словами, это первый общий коммит у «предков» коммитов C1 и X1), допустим, это C0;
  • как только такой коммит найден, git вычисляет разницу между коммитами C0 и X1 и пытается наложить этот патч на рабочую копию;
  • если патч удалось наложить без конфликтов, то создаётся новый merge-commit, у которого в качестве родительских коммитов указаны два коммита, а не один, как обычно;
  • если патч автоматически наложить не удалось, процесс прерывается и пользователь должен самостоятельно разрешить конфликты, после чего продолжить мержинг, после чего так же создаётся коммит с двумя родителями.

Для демонстрации я сделал несколько коммитов и веток в нашем образцовом репозитории, на которых сейчас весь процесс продемонстрирую максимально подробно.

Сначала переключим рабочую копию на ветку ss-master, в которую будем мержить ветку origin/ss-branch:

$ git checkout ss-master
Branch ss-master set up to track remote branch ss-master from origin.
Switched to a new branch 'ss-master'

Выше я уже писал, что тут на самом деле создаётся новая локальная ветка с апстримом на внешнюю.

Вот как в целом выглядит кусок графа коммитов для нашего примера:

merge initial state

И вот как он же выглядит в gitk (команда gitk HEAD origin/ss-branch, она нам показывает граф коммитов так, чтобы были одновременно видны коммиты из референсов HEAD и origin/ss-branch):

merge initial state - gitk

Что мы видим из этого графа:

  • зелёной рамкой помечен коммит, в котором сейчас находится рабочая копия (то есть HEAD);
  • ветки ss-branch и ss-master разошлись на коммите с идентификатором 6852a85;
  • в ветке ss-branch было сделано два коммита после 6852a85;
  • в ветке ss-master было сделано три коммита после 6852a85;
  • коммит 0d2b0ed одновременно «входит» в ветки ss-master и origin/ss-master.

Наша цель: смержить ветку origin/ss-branch на локальную ветку ss-master, в результате этого в локальной ветке ss-master должны появиться те изменения, которые были сделаны в ss-branch после расхождения на коммите 6852a85. Эти изменения появляются в виде специального коммита, который принято называть merge commit.

Для начала я очень рекомендую запустить gitk в каталоге рабочей копии и посмотреть, какие именно изменения были в коммитах. Так как мы хотим посмотреть изменения сразу в двух ветках, запускать нужно с указанием двух референсов: gitk HEAD origin/ss-branch. Все изменения были в одном файле SAMPLE.txt.

Если мы выполним команду git merge без аргументов, то увидим логичное сообщение:

$ git merge
Already up-to-date.
$

git merge без аргументов пытается смержить в локальную рабочую копию апстрим-ветку, но сейчас сама ветка и её апстрим указывают на один коммит, поэтому делать ничего не нужно. Поэтому укажем явно, что мы хотим смержить ветку origin/ss-branch в нашу рабочую копию:

$ git merge origin/ss-branch
Auto-merging SAMPLE.txt
CONFLICT (content): Merge conflict in SAMPLE.txt
Automatic merge failed; fix conflicts and then commit the result.
$

Я специально сделал эти коммиты такими, чтобы попытка мержинга вызвала конфликт: автоматическая система не смогла надёжно слить изменения, поэтому будем делать это вручную.

Сначала смотрим статус рабочей копии:

$ git status
On branch ss-master
Your branch is up-to-date with 'origin/ss-master'.
You have unmerged paths.
  (fix conflicts and run "git commit")

Unmerged paths:
  (use "git add <file>..." to mark resolution)

        both modified:   SAMPLE.txt

no changes added to commit (use "git add" and/or "git commit -a")
$

Разрешение конфликта происходит в текстовом редакторе, вы должны открыть файл SAMPLE.txt и отредактировать его нужным образом. Сейчас содержимое файла выглядит так:

This file has been created for demonstration purposes. It is not a part of boostrap repository.

<<<<<<< HEAD
This line was added by second commit.

This is another line, 4th commit.

And this line was added in the third commit.
=======
This line was introduced by first commit in branch ss-branch.

And another one.
>>>>>>> origin/ss-branch

Проблемный блок (который система не смогла автоматически пропатчить) находится между строчкам с маркерами <<<<<<< COMMIT-1 и >>>>>>> COMMIT-2. Блок состоит из двух частей, разделённых маркером =======, выше этого маркера текст из рабочей копии, ниже — из той ветки, которую мы мержим (origin/ss-branch).

Чтобы корректно разрешить конфликт, вы должны вместо проблемного блока оставить правильный код. Под правильным подразумевает код, содержащий все необходимые изменения. В нашей ситуации всё достаточно просто: мы должны оставить код из обоих частей, отредактируем файл, чтобы его содержимое стало таким:

This file has been created for demonstration purposes. It is not a part of boostrap repository.

This line was added by second commit.

This is another line, 4th commit.

And this line was added in the third commit.

This line was introduced by first commit in branch ss-branch.

And another one.

После разрешения конфликтов в файле не должно остаться служебных маркеров <<<<<<<, ======= и >>>>>>>!

Теперь нужно сказать git, что конфликты в файле убраны, делается это командой git add (и не забываем про git status):

$ git add SAMPLE.txt
$ git status
On branch ss-master
Your branch is up-to-date with 'origin/ss-master'.
All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

Changes to be committed:

        modified:   SAMPLE.txt

Теперь нужно завершить мерж, делается это командой git commit (это написано в выводе сообщения о статусе). В процессе откроется редактор, где нужно будет ввести сообщение для нового коммита, его шаблон уже подготовлен и в нём много полезной информации в комментариях. Текст шаблона, в принципе, уже нас устраивает, поэтому сохраняем файл и закрываем редактор.

$ git commit
[ss-master 81c5a7e] Merge remote-tracking branch 'origin/ss-branch' into ss-master
$

Сообщение после команды нам говорит, что был создан мерж-коммит с идентификатором 81c5a7e. Сразу же посмотрим, что из себя представляет этот коммит во внутренней базе объектов:

$ git cat-file -p 81c5a7e
tree 2c2d9e7697d1089701c33a8be38ea9f6ca99d163
parent 0d2b0ed93a76c95391c9266d238c375d7777b65e
parent b01834558d3f363e78a1c51a78363d42980704a5
author Sergey Stolyarov <[email protected]> 1478428844 +0700
committer Sergey Stolyarov <[email protected]> 1478428844 +0700

Merge remote-tracking branch 'origin/ss-branch' into ss-master

Видим, что у коммита 81c5a7e не один, а два родителя, это отражает факт слияния. Модифицированный мерж-коммитом граф выглядит так:

commit graph after merge

Если вы все эти операции проделали у себя, то результирующий мерж-коммит у вас будет иметь другой идентификатор, так как в подсчёт идентификатора (то есть SHA1-суммы) входит имя, емейл и время, а у вас они будут другими.

А вот как это выглядит в gitk:

gitk after merge

Сразу же несколько важных наблюдений:

  • метка локальной ветки ss-master теперь указывает на другой коммит, при этом на предыдущем коммите этой ветки по-прежнему висит метка ветки origin/ss-master, то есть наша локальная ветка теперь на один коммит впереди апстрима;
  • соответственно, теперь в рабочей копии находится другой коммит;
  • первый коммит-родитель (parent 0d2b0ed93a76c95391c9266d238c375d7777b65e) — это коммит, на который вы мержили второй коммит-родитель (parent b01834558d3f363e78a1c51a78363d42980704a5).

Важное замечание. Никогда не делайте merge на рабочей копии с незакоммиченными изменениями, это чревато непредсказуемыми и сложнорешаемыми проблемами.

Если в процессе мержинга что-то пошло не так, например, вы удалили лишнее при правке конфликтного файла, вы можете полностью отменить merge и вернуться на состояние рабочей копии до команды git merge. Делается это командой git merge --abort. Однако это корректно сработает только при условии, что перед началом мержинга у вас не было незакоммиченных изменений.

Выполняйте merge только на рабочей копии, где нет незакоммиченных изменений.

Этот пункт я дополнительно вынес отдельным разделом, так как это действительно важно. Всегда запускайте git status перед началом мержинга. Если у вас есть незакоммиченные изменения, можно воспользоваться командой git stash, о ней я расскажу чуть ниже.

История коммитов и относительные референсы

Существуют специальные динамические референсы, указывающие на определённые коммиты в истории.

<rev>~<n>, где ~ символ тильды
указывает на коммит, стоящий <n> коммитов назад по истории, переходы идут по первому коммиту-родителю. Примеры: HEAD~1, HEAD~ (эквивалентно HEAD~1), HEAD~~, HEAD~2 (эквивалентно HEAD~~). Вместо HEAD может быть любой другой референс или идентификатор коммита.
<rev>^<n>, где ^ символ «крышки»
указывает на <n> родителя коммита, например, HEAD^ эквивалентно HEAD^1 и указывает на первого родителя, HEAD^2 указывает на второго предка (имеет смысл только для merge-коммитов). В некоторых оболочках, например, zsh, такой референс нужно заключать в кавычки, чтобы исключить дополнительные попытки оболочки распарсить этот параметр.

В качестве примера посмотрим на рисунок выше с репозиторием после мержинга, я на нём развесил дополнительные метки с референсами:

commit graph after merge

Относительные референсы можно использовать наравне с обычными. Например, если вы хотите посмотреть diff между HEAD и вторым его предком:

$ git diff HEAD 'HEAD^2'
diff --git a/SAMPLE.txt b/SAMPLE.txt
index 3f78308..50834fb 100644
--- a/SAMPLE.txt
+++ b/SAMPLE.txt
@@ -1,11 +1,5 @@
 This file has been created for demonstration purposes. It is not a part of boostrap repository.

-This line was added by second commit.
-
-This is another line, 4th commit.
-
-And this line was added in the third commit.
-
 This line was introduced by first commit in branch ss-branch.

 And another one.

git push

Операция git push отправляет коммиты из локального репозитория на сервер внешнего, также она обновляет ветки и теги.

git push работает исключительно с коммитами, изменения в рабочей копии в операции не участвуют.

По умолчанию git push отправляет изменения только на один сервер, если запускать без параметров, то используется URL внешнего репозитория текущей ветки, если он не задан, то используется URL внешнего репозитория origin. Вы также можете прямо указать URL внешнего сервера, куда вы хотите отправить изменения.

Также существуют определённые правила, как именно выбираются коммиты для отправки. Вы можете в аргументах команды указать, какие именно локальные референсы вы хотите отправить и какие референсы на внешнем сервере должны быть ими обновлены. Формат этого аргумента детально описан в мануале git push, здесь я в подробности вдаваться не буду, так как это достаточно сложная тема.

Если параметры обновляемых референсов не указаны, то используется стратегия, определяемая параметром конфига push.default:

nothing
команда git push будет выдавать ошибку, если явно не указаны параметры референсов для отправки;
current
будут запушены только коммиты из текущей ветки, целевый референсом будет ветка на внешнем сервере с таким же именем;
upstream
будут запушены только коммиты из текущей ветки, целевым референсом будет выбран апстрим текущей ветки (я выше писал, что это такое);
simple
работает как uptream, только дополнительно проверяется, что имя ветки апстрима совпадает с именем локальной ветки, по сути это означает, что push отработает только если для ветки в конфиге явно задан апстрим и ветка на внешнем сервере имеет такое же имя, что и локальная. Этот режим используется по умолчанию, начиная с версии git 2.0.
matching
будут запушены все ветки, имена которых совпадают с ветками на внешнем сервере. Этот режим использовался по умолчанию до версии git 2.0.

Самый безопасная стратегия — simple, именно она используется по умолчанию с версии git 2.0, если параметр конфига push.default явно не указан. Остальные стратегии следует использовать с осторожностью, особенно matching.

У git push есть ещё одно ограничение — по умолчанию команда не позволит вам запушить коммиты, если текущий верхний коммит ветки на внешнем сервере не является коммитом из истории локальной ветки. По сути это означает, что вы не сможете отправить коммиты на внешний сервер, если в вашей локальной ветке отсутствуют коммиты, добавленные во внешнюю ветку с момента последней синхронизации. Обычно это возникает, когда во внешнюю ветку кто-то ещё запушил коммиты после вашей последней синхронизации через git pull. Поэтому обычно перед git push рекомендуется сделать git pull. push можно зафорсить аргументом --force, однако это можно использовать, только если вы абсолютно точно осознаёте, что именно делаете.

Удаляйте ненужные локальные ветки

Удаляйте полностью синхронизованные локальные ветки. Они действительно не нужны, а иногда даже и вредны, так как локальная ветка автоматически не синхронизуется с апстрима и можно случайно замержить не то.

git stash

Команда git stash сохраняет изменения в рабочей копии относительно HEAD и записывает их в виде патча в отдельное хранилище, после чего откатывает все изменения до HEAD. Затем можно эти изменения вытащить из хранилища и применить на рабочую копию.

Сохранение делается командой git stash save (это эквивалентно запуску без аргументов: git stash) или git stash save MESSAGE, чтобы создать именованный стеш.

Все стеши записываются в стек, команда git stash pop извлекает последний добавленный стеш и пытается его применить как патч на рабочую копию. Если процесс успешно завершается, стеш удаляется из стека.

Есть два базовых сценария, когда stash очень полезен:

  • сохранить стеш → сделать merge на текущую рабочую копию → восстановить стеш;
  • в процессе работы вы обнаруживаете, что работаете не с той веткой: сохранить стеш → переключиться на другую ветку → восстановить стеш.

Посмотреть весь стек стешей можно командой git stash --list.

Не следует увлекаться этой командой. В нормальном репозитории стек стешей должен быть пустым, и срок жизни записи в стеше не должен превышать нескольких минут.

Перед тем как запустить команду git stash всегда запускайте сначала git stash list, чтобы убедиться, что стек стешей пустой, если он не пустой, постарайтесь выяснить, что там лежит.

Вот примерно так выглядит работа со стешем:

$ git stash
Saved working directory and index state WIP on ss-master: 81c5a7e Merge remote-tracking branch 'origin/ss-branch' into ss-master
HEAD is now at 81c5a7e Merge remote-tracking branch 'origin/ss-branch' into ss-master
$ git stash list
stash@{0}: WIP on ss-master: 81c5a7e Merge remote-tracking branch 'origin/ss-branch' into ss-master

Здесь stash@{0} — идентификатор стеша в списке, вы его можете использовать как аргумент команды pop: git stash pop stash@{0}.

Обратите внимание, что в стеш попадают только отслеживаемые файлы! Если у вас в команде git status есть файлы в секции Untracked files, они в стеш не попадут, если вы их тоже хотите запомнить, добавьте сначала командой git add.

Вы можете удалить элементы из списка стешей командой git stash drop <STASH-ID>, например, git stash drop stash@{0}.

Никогда не удаляйте склонированный репозиторий

xkcd 1597

Очень много людей в случае каких-нибудь проблем просто удаляют весь каталог репозитория и клонируют его заново. Это очень плохое решение: когда вы удаляете локальный репозиторий, вы теряете все локальные ветки, коммиты и теги, теряете рефлог, теряете возможность вернуться к промежуточным коммитам, которые были только в этом локальном репозитории, теряете стеш и много чего ещё.

Всегда старайтесь разобраться в проблеме и решить её без радикальных методов.

Аутентичность коммитов

В subversion принадлежность коммита определяется авторизационными данными пользователя, который коммит отправил на сервер. В git ничего такого нет. Вы можете в коммите указать любой email и любое имя и он будет успешно создан.

Однако вы можете подписывать коммиты своим PGP-ключом через gnupg. Естественно, у вас должен быть установлен и настроен gnupg для этого. Коммит будет подписан, если вы добавите аргумент -S<keyid>, например, так:

$ git commit [email protected] -m "Signing example, see file GPG.md for details"

В моём образцовом репозитории добавлена ветка signed-branch, последний коммит в которой подписан моим PGP-ключом. Если у вас установлен gnupg, вы можете проверифицировать этот коммит:

$ git log --show-signature -1 origin/signed-branch
commit d3c7f795c0f1a7f57598bdfcfcf79484e5c4bac6
gpg: Signature made Mon Nov  7 20:38:25 2016 KRAT using RSA key ID 5B20C7D2
gpg: Can't check signature: No public key
Author: Sergey Stolyarov <[email protected]>
Date:   Mon Nov 7 20:38:25 2016 +0700

    Signing example, see file GPG.md for details

Верификация не прошла, так как у вас, скорее всего, нет моего публичного ключа, вы можете его добавить командой:

$ gpg --keyserver hkp://pool.sks-keyservers.net --recv-keys 0x4695E6355B20C7D2

И проверить снова:

$ git log --show-signature -1 origin/signed-branch
commit d3c7f795c0f1a7f57598bdfcfcf79484e5c4bac6
gpg: Signature made Mon Nov  7 20:38:25 2016 KRAT using RSA key ID 5B20C7D2
gpg: Good signature from "Sergey Stolyarov <[email protected]>" [unknown]
gpg:                 aka "[jpeg image of size 2017]" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 87E2 946B 041B CF31 8C4D  5BA7 4695 E635 5B20 C7D2
Author: Sergey Stolyarov <[email protected]>
Date:   Mon Nov 7 20:38:25 2016 +0700

    Signing example, see file GPG.md for details

На низком уровне цифровая подпись является частью объекта-коммита, то есть подпись нельзя изменить без изменения идентификатора коммита.

Помимо коммитов вы также можете подписывать аннотированные теги:

$ git tag -as -u [email protected] signed-tag 

Проверяется подпись у тега так:

$ git tag -v signed-tag
object d1171ac44ad05a1b7244900b690840093d3e5573
type commit
tag signed-tag
tagger Sergey Stolyarov <[email protected]> 1478527053 +0700

This is gnupg-signed key that points to origin/signed-branch
gpg: Signature made понедельник,  7 ноября 2016 г.  using RSA key ID 5B20C7D2
gpg: Good signature from "Sergey Stolyarov <[email protected]>" [unknown]
gpg:                 aka "[jpeg image of size 2017]" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg:          There is no indication that the signature belongs to the owner.
Primary key fingerprint: 87E2 946B 041B CF31 8C4D  5BA7 4695 E635 5B20 C7D2

Коммит, на который указывает аннотированный тег, находится внутри подписанных данных, поэтому его нельзя изменить, не зная секретного ключа. Также обратите внимание, что тег указывает на конкретный идентификатор коммита, а не на имя ветки.

Магия .gitignore

Файл .gitignore позволяет указать маски файлов, на которые git не должен обращать внимания. Обычно туда добавляются файлы и каталоги, возникающие в процессе сборки продукта, рабочие конфиги, файлы с логами и так далее. .gitignore действует, начиная с каталога, в котором он лежит. Таким образом, вы можете создать глобальный файл и локальные в подкаталогах.

Пользуйтесь .gitignore, это очень полезно, но будьте крайне осторожны, если вы добавить туда лишнее, то эти файлы не будут добавлены в репозиторий и не будут отслеживаться.

Читайте маны

В составе git идёт исключительно детальная встроенная документация в виде man-файлов. В них содержится абсолютно всё, что только может потребоваться, все фичи подробно документированы и часто снабжены большим количеством примеров.

Например, ман команды git checkout можно открыть так man git-checkout или так git help checkout.

На официальном сайте системы в свободном доступе выложена книга Pro Git, в ней описано практически всё.

Книга полностью переведена на русский язык.

Нераскрытые темы

В этом тексте совсем не рассматриваются некоторые темы, например, rebase. Однако уже написанного должно быть достаточно, чтобы вы поняли суть нераскрытых тем исключительно из мануалов и документации.

Также здесь не рассматриваются конкретные сценарии использования git, это слишком масштабная тема, достойная отдельной статьи.

Если какие-то из рассмотренных моментов по-прежнему вызывают вопросы, можете спрашивать прямо в комментариях, я постараюсь отреагировать на все замечания и буду периодически обновлять текст.

Комментарии

Гость: Alexander Turenko | 2016-11-08 в 04:34

Я бы еще посоветовал, если неясно, что происходит, заглядывать в:

$ gitk --all # (отобразить все ветки, опция работает и для git log) $ git log --decorate=full # (отметить позиции локальных, удаленных веток и тегов)

Гость: Nikolay | 2016-11-17 в 17:44

Тогда уж

$ git log --decorate=full --graph --all # Отобразить ещё и связи между коммитами

Sergey Stolyarov | 2016-11-08 в 14:45

Да, спасибо, секцию про git log я из итогового варианта убрал, так как хотел переписать, и забыл назад вернуть. Про gitk добавил.

Текст комментария (допустимая разметка: *курсив*, **полужирная**, [ссылка](http://example.com) или <http://example.com>) Посетители-анонимы, обратите внимение, что более чем одна гиперссылка в тексте (включая оную из поля «веб-сайт») приведёт к блокировке комментария для модерации. Зайдите на сайта с использованием аккаунта на twitter, например, чтобы посылать комментарии без этого ограничения.
Имя (обязательно, 50 символов или меньше)
Опциональный email, на который получать ответы (не будет опубликован)
Веб-сайт
© 2006—2024 Sergey Stolyarov | Работает на pyrengine